Skip to content

fix: pin Readable.read() semantics to the compat matrix primary - #15

Merged
filipeforattini merged 1 commit into
mainfrom
readable-primary-semantics
Sep 2, 2026
Merged

fix: pin Readable.read() semantics to the compat matrix primary#15
filipeforattini merged 1 commit into
mainfrom
readable-primary-semantics

Conversation

@filipeforattini

Copy link
Copy Markdown
Member

O problema

O PR #14 pinou o oráculo diferencial no primário da matriz e, com isso, expôs uma incoerência que já estava no runtime: ele era metade Node 24, metade Node 26.

A frente anterior (4edcb938) implementou a semântica do nodejs/node#60441 (semver-major, 26.0.0) nos três runtimes — mas sondou o node:stream local (26.8.1) antes de a doutrina do primário existir. O resultado ficou pela metade: o read() pausado devolvia chunk-a-chunk (26) enquanto o async-iterator concatenava (24); 2845/2846 vermelhas contra o oráculo primário; 1746/2813 fixando o pré-26.

Um binário compilado reproduz UM Node. Aplicando a doutrina que o #14 mergeou, este PR faz o runtime implementar a semântica do PRIMARY (24.15) de forma coerente em toda a superfície de streams, e deixa o ponto de mudança documentado para quando o primário for promovido a 26.

Sondagem: Node 24.15.0 vs 26.8.1

Matriz completa rodada nos dois binários reais (bare read pausado/fluindo, read(n), com/sem decoder, unshift depois de push e de read parcial, async-iterator). As duas majors divergem em UMA regra só, howMuchToRead(NaN):

Node 24:  state.flowing && state.length ? state.buffer.first().length : state.length
Node 26:  !state.decoder                ? state.buffer.first().length : state.length

Em 24 um read() pausado colapsa a fila inteira e só o modo flowing caminha chunk a chunk; em 26 um stream sem decoder devolve a entrada da cabeça nos dois modos.

sonda Node 24.15.0 Node 26.8.1
bare read pausado ["aa","bb","cc"] "aabbcc", null "aa", "bb", "cc", null
bare read após read(3) de ["hello ","world"] "hel", "lo world", null "hel", "lo ", "world", null
read(3) cruzando fronteira, depois bare "aab", "bcc", null "aab", "b", "cc"
read(10) curto, antes do EOF null null
read(10) curto, depois do EOF "aabb" "aabb"
read(4) exato "aabb" "aabb"
read(0), depois bare null, "aabb" null, "aa"
bare read COM decoder (setEncoding) "aabbcc", null "aabbcc", null
read(3) com decoder, depois bare "hel", "lo world" "hel", "lo world"
objectMode bare read {i:1}, {i:2}, null {i:1}, {i:2}, null
unshift na frente push("bc"); unshift("a") "abc", null "a", "bc", null
unshift LIFO "abc", null "a", "b", "c"
unshift após read(3) parcial "hel", "XYlo world" "hel", "XY", "lo ", "world"
unshift em fila vazia "z", null "z", null
read(2) fatiando unshift "ab", "cde", null "ab", "c", "de"
data events (flowing) ["aa","bb","cc"] ["aa","bb","cc"]
dreno em handler readable ["aabbcc"] ["aa","bb","cc"]
async-iterator, bufferizado ["one two"] ["one ","two"]
async-iterator, com decoder ["one two"] ["one two"]
Readable.from([...]) / from("whole") ["a","bc"] / ["whole"] idem
async-iterator, produtor lento ["t1 ","t2 "] ["t1 ","t2 "]

Duas leituras importantes:

  1. Tudo o que não é a regra do read() bate entre as majorsread(n), o read curto que espera o EOF, o read exato, objectMode, read(0), os data events em flowing, Readable.from, o produtor lento, e o caso com decoder (26 continua concatenando quando há StringDecoder).
  2. A regra única se observa por três caminhos — o read() direto, o dreno num handler readable, e o async-iterator. É exatamente por isso que a divergência aparecia como "metade e metade": não eram três bugs, era um só, visível de três ângulos.

O que reverteu vs. o que manteve

Revertido (era semântica 26):

  • packages/runtime/src/scr_stream.c — o gate flowing == 1 || !encoded dos dois pontos de howMuchToRead (antes e depois do refill) volta a flowing == 1.
  • packages/runtime/src/island-js/13-stream.js — o read() sem argumento volta a colapsar a fila (_takeAll()). Ali read() é sempre o caminho pausado, porque flowing entrega por _drainData()/_takeChunk().
  • packages/runtime-rust/src/readable.rs — o bare read volta a pedir available em vez do tamanho do chunk da cabeça (o head fica sem uso e sai).

Mantido (já batia com 24, confirmado nas sondas acima):

  • read(n) curto que espera o EOF e só então libera o restante; e read(n) exatamente igual ao bufferizado, que colapsa a fila.
  • read(NaN) tratado como a forma ausente.
  • unshift — a ordem LIFO e a inserção na frente nunca estiveram erradas. O que fazia o unshift parecer vazado era o read() do outro lado.

Ponto único de mudança

Os três runtimes carregam agora, na própria expressão, as duas formas (24 e 26) e a nota de que read() segue NODE_COMPAT_MATRIX.primary, com o ponteiro para o nodejs#60441. Promover o primário a 26 é editar três expressões.

Como ainda não existe perfil de node:stream (só events/fetch/url, e o de fetch cobre Web Streams, não o Readable), o checklist de promoção ficou no cabeçalho de packages/compiler/src/compat/node-matrix.ts, numa seção nova — "Semantics the primary DECIDES (not just labels)". Ela nomeia a distinção que faltava: a maior parte da matriz é censo (um membro existe numa major e não na outra, e as duas respostas ficam gravadas), mas algumas linhas são de outra natureza — as duas majors dão respostas diferentes para a mesma chamada, e o binário só reproduz uma. Essas seguem o primário, e cada uma delas entra nessa lista para que uma promoção seja um checklist e não uma escavação.

Prova de que o runtime é coerentemente 24

A mesma lane npm sob o oráculo 26 fica vermelha em exatamente dois cenários, com o runtime emitindo as respostas do 24:

FAIL  npm differential (63 programs) > stream-shims
- paused-read: b:hel|b:lo |world      (esperado por Node 26)
+ paused-read: b:hel|b:lo world|null  (o que o runtime emite = Node 24)
- unshift: b:a                        (esperado por Node 26)
+ unshift: b:abc                      (o que o runtime emite = Node 24)

A divergência está confinada ao nodejs#60441 — nada mais na superfície de streams separa as duas majors.

Fixtures

2845/2846 reescritas contra a verdade do 24, cobrindo agora as duas metades da regra: o dreno pausado (que colapsa) e o flowing (que caminha), para que o gate não possa ser achatado nem para um lado nem para o outro. 1746/2813 seguem verdes sem toque.

Removi de 2845 o caso de read() com decoder: o compilador ainda não tem inspect lowering para Uint8Array | null (SC1090) e o runtime Rust lança em read() sobre stream com encoding. É o caso em que as duas majors concordam, então não carrega valor de pinagem — 1744-stream-set-encoding já o cobre.

Placar

Tudo rodado com o vitest sob o Node 24.15 pinado, com o PATH incluído — npm.test.ts spawna um node nu como oráculo, então a lane só está pinada se o PATH estiver.

gate resultado
differential C -t stream 45/45
differential C -t readable 8/8 (2845, 2846, 2813 inclusos)
rust-differential -t stream 45/45
rust-differential -t readable 8/8 (a lane Rust reivindicou 2845/2846, então readable.rs rodou de fato)
npm lane C (Node 24) 68/68, stream-shims verde
npm-static 30/30
cargo test 139/139
clippy limpo
gen-island-bootstrap --check bate
lint 0 erros

Duas falhas fora do escopo — island static hello-world stays in its size class e surface-manifest every attestation-demoting libFn spelling is deniable by a manifest-id fence — já falham idênticas em origin/main, medido com o worktree limpo (stash) e rebuildado.

🤖 Generated with Claude Code

https://claude.ai/code/session_01L4tTZUEZzWnw3rHKVQDTMn

O runtime estava METADE Node 24, METADE Node 26. A frente anterior
(4edcb93) implementou a semantica do nodejs#60441 nos tres runtimes, mas
sondou o node:stream local (26.8.1) antes de a doutrina do primario
existir. O resultado: o read() pausado devolvia chunk-a-chunk (26)
enquanto o async-iterator concatenava (24), e as fixtures 2845/2846
ficaram vermelhas contra o oraculo primario que o PR #14 pinou.

Um binario compilado reproduz UM Node. Aplicando a doutrina merged, o
runtime passa a implementar a semantica do PRIMARY (24.15) de forma
coerente em toda a superficie de streams.

Sondagem do Node 24.15.0 REAL, lado a lado com o 26.8.1 (matriz completa:
bare read pausado/fluindo, read(n), com/sem decoder, unshift, iterador):
as duas majors divergem em UMA regra so, howMuchToRead(NaN):

    Node 24:  state.flowing && state.length ? head : state.length
    Node 26:  !state.decoder               ? head : state.length

Ou seja, em 24 um read() PAUSADO colapsa a fila inteira e so o modo
FLOWING caminha chunk a chunk; em 26 um stream sem decoder devolve a
entrada da cabeca nos dois modos. Tudo o mais bate entre as majors:
read(n) fatiando fronteiras, read curto esperando o EOF, read(n) exato,
objectMode, read(0), os data events em flowing, Readable.from, e o
iterador com decoder. E a regra unica se observa por tres caminhos (o
read() direto, o dreno num handler 'readable' e o async-iterator), que e
exatamente a incoerencia que estava aparecendo como "metade e metade".

Revertido (era semantica 26):

- scr_stream.c: o gate `flowing == 1 || !encoded` dos dois pontos de
  howMuchToRead (antes e depois do refill) volta a `flowing == 1`.
- island-js/13-stream.js: o read() sem argumento volta a colapsar a fila
  (`_takeAll()`); read() ali e sempre o caminho PAUSADO, porque flowing
  entrega por _drainData()/_takeChunk().
- runtime-rust/readable.rs: o bare read volta a pedir `available` em vez
  do tamanho do chunk da cabeca (o `head` fica sem uso e sai).

Mantido (ja batia com 24, verificado nas sondas):

- read(n) curto que espera o EOF e so entao libera o restante, e o
  read(n) exatamente igual ao bufferizado que colapsa a fila.
- read(NaN) tratado como a forma ausente.
- unshift: a ordem LIFO e a insercao na frente nunca estiveram erradas —
  o que fazia o unshift PARECER vazado era o read() do outro lado.

Marcado o ponto unico de mudanca: os tres runtimes carregam, na propria
expressao, as duas formas (24 e 26) e a nota de que read() segue
NODE_COMPAT_MATRIX.primary; ver nodejs#60441 para a migracao. Como ainda
nao existe perfil de node:stream, o checklist de promocao do primario
ficou no cabecalho de compat/node-matrix.ts, na secao nova "Semantics the
primary DECIDES (not just labels)" — a distincao entre uma linha de censo
(as duas respostas sao gravadas) e uma linha que o primario DECIDE (o
binario so reproduz uma).

Fixtures 2845/2846 reescritas contra a verdade do 24, cobrindo agora as
DUAS metades da regra: o dreno pausado (que colapsa) e o flowing (que
caminha), para que o gate nao possa ser achatado nem para um lado nem
para o outro. 1746/2813 seguem verdes sem tocar.

Prova de que o runtime e coerentemente 24: a mesma lane npm sob o oraculo
26 fica VERMELHA em exatamente stream-shims/paused-read e
stream-shims/unshift, com o runtime emitindo as respostas do 24
("b:lo world", "b:abc") contra as expectativas do 26 ("b:lo |world",
"b:a") — a divergencia esta confinada ao nodejs#60441.

Placar (vitest sob o Node 24.15 pinado, PATH incluido, porque npm.test.ts
spawna um "node" nu como oraculo): differential C -t stream 45/45 e -t
readable 8/8; rust-differential -t stream 45/45 e -t readable 8/8 (a lane
Rust reivindicou 2845/2846, entao readable.rs rodou de fato); npm lane C
68/68 com stream-shims verde; npm-static 30/30; cargo test 139/139;
clippy limpo; gen-island-bootstrap --check bate; lint 0 erros.

Duas falhas fora do escopo (island "static hello-world size class" e
surface-manifest "attestation-demoting libFn fence") ja falham identicas
em origin/main, medido com o worktree limpo e rebuildado.

Claude-Session: https://claude.ai/code/session_0197JoEpMBBqqkSiBb2vNX5A
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant